diff --git a/lib/reducers/lifecycle-state-reducer.js b/lib/reducers/lifecycle-state-reducer.js index 5c20f1c8e..fddbbb300 100644 --- a/lib/reducers/lifecycle-state-reducer.js +++ b/lib/reducers/lifecycle-state-reducer.js @@ -1,22 +1,19 @@ // @flow +import type { LifecycleState } from '../types/lifecycle-state-types'; import type { BaseAction } from '../types/redux-types'; export const unsupervisedBackgroundActionType = 'UNSUPERVISED_BACKGROUND'; export const updateLifecycleStateActionType = 'UPDATE_LIFECYCLE_STATE'; export default function reduceLifecycleState( - state: boolean, + state: LifecycleState, action: BaseAction, -): boolean { +): LifecycleState { if (action.type === unsupervisedBackgroundActionType) { - return false; + return 'background'; } else if (action.type === updateLifecycleStateActionType) { - if (action.payload === 'active') { - return true; - } else if (action.payload === 'background') { - return false; - } + return action.payload; } return state; } diff --git a/lib/shared/lifecycle-utils.js b/lib/shared/lifecycle-utils.js index 30ab7b676..955b42352 100644 --- a/lib/shared/lifecycle-utils.js +++ b/lib/shared/lifecycle-utils.js @@ -1,7 +1,7 @@ import { useSelector } from 'react-redux'; function useIsAppForegrounded() { - return useSelector((state) => state.foreground); + return useSelector((state) => state.lifecycleState !== 'background'); } export { useIsAppForegrounded }; diff --git a/lib/socket/calendar-query-handler.react.js b/lib/socket/calendar-query-handler.react.js index 74c1d143a..499e16873 100644 --- a/lib/socket/calendar-query-handler.react.js +++ b/lib/socket/calendar-query-handler.react.js @@ -1,145 +1,146 @@ // @flow import _isEqual from 'lodash/fp/isEqual'; import * as React from 'react'; import { updateCalendarQueryActionTypes, updateCalendarQuery, } from '../actions/entry-actions'; import { timeUntilCalendarRangeExpiration } from '../selectors/nav-selectors'; import { useIsAppForegrounded } from '../shared/lifecycle-utils'; import type { CalendarQuery, CalendarQueryUpdateResult, CalendarQueryUpdateStartingPayload, } from '../types/entry-types'; +import { type LifecycleState } from '../types/lifecycle-state-types'; import { type ConnectionInfo } from '../types/socket-types'; import { type DispatchActionPromise, useDispatchActionPromise, useServerCall, } from '../utils/action-utils'; import { useSelector } from '../utils/redux-utils'; type BaseProps = {| +currentCalendarQuery: () => CalendarQuery, +frozen: boolean, |}; type Props = {| ...BaseProps, +connection: ConnectionInfo, +lastUserInteractionCalendar: number, - +foreground: boolean, + +foreground: LifecycleState, +dispatchActionPromise: DispatchActionPromise, +updateCalendarQuery: ( calendarQuery: CalendarQuery, reduxAlreadyUpdated?: boolean, ) => Promise, |}; class CalendarQueryHandler extends React.PureComponent { serverCalendarQuery: CalendarQuery; expirationTimeoutID: ?TimeoutID; constructor(props: Props) { super(props); this.serverCalendarQuery = this.props.connection.actualizedCalendarQuery; } componentDidMount() { if (this.props.connection.status === 'connected') { this.possiblyUpdateCalendarQuery(); } } componentDidUpdate(prevProps: Props) { const { actualizedCalendarQuery } = this.props.connection; if (this.props.connection.status !== 'connected') { if (!_isEqual(this.serverCalendarQuery)(actualizedCalendarQuery)) { this.serverCalendarQuery = actualizedCalendarQuery; } return; } if ( !_isEqual(this.serverCalendarQuery)(actualizedCalendarQuery) && _isEqual(this.props.currentCalendarQuery())(actualizedCalendarQuery) ) { this.serverCalendarQuery = actualizedCalendarQuery; } const shouldUpdate = (this.isExpired || prevProps.connection.status !== 'connected' || this.props.currentCalendarQuery !== prevProps.currentCalendarQuery) && this.shouldUpdateCalendarQuery; if (shouldUpdate) { this.updateCalendarQuery(); } } render() { return null; } get isExpired() { const timeUntilExpiration = timeUntilCalendarRangeExpiration( this.props.lastUserInteractionCalendar, ); return ( timeUntilExpiration !== null && timeUntilExpiration !== undefined && timeUntilExpiration <= 0 ); } get shouldUpdateCalendarQuery() { if (this.props.connection.status !== 'connected' || this.props.frozen) { return false; } const calendarQuery = this.props.currentCalendarQuery(); return !_isEqual(calendarQuery)(this.serverCalendarQuery); } updateCalendarQuery() { const calendarQuery = this.props.currentCalendarQuery(); this.serverCalendarQuery = calendarQuery; this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery(calendarQuery, true), undefined, ({ calendarQuery }: CalendarQueryUpdateStartingPayload), ); } possiblyUpdateCalendarQuery = () => { if (this.shouldUpdateCalendarQuery) { this.updateCalendarQuery(); } }; } export default React.memo(function ConnectedCalendarQueryHandler( props: BaseProps, ) { const connection = useSelector((state) => state.connection); const lastUserInteractionCalendar = useSelector( (state) => state.entryStore.lastUserInteractionCalendar, ); // We include this so that componentDidUpdate will be called on foreground const foreground = useIsAppForegrounded(); const callUpdateCalendarQuery = useServerCall(updateCalendarQuery); const dispatchActionPromise = useDispatchActionPromise(); return ( ); }); diff --git a/lib/types/redux-types.js b/lib/types/redux-types.js index 870acd223..bca265eca 100644 --- a/lib/types/redux-types.js +++ b/lib/types/redux-types.js @@ -1,828 +1,828 @@ // @flow import type { LogOutResult, LogInStartingPayload, LogInResult, RegisterResult, } from './account-types'; import type { ActivityUpdateSuccessPayload, QueueActivityUpdatesPayload, SetThreadUnreadStatusPayload, } from './activity-types'; import type { RawEntryInfo, EntryStore, CalendarQuery, SaveEntryPayload, CreateEntryPayload, DeleteEntryResponse, RestoreEntryPayload, FetchEntryInfosResult, CalendarQueryUpdateResult, CalendarQueryUpdateStartingPayload, } from './entry-types'; import type { CalendarFilter, CalendarThreadFilter, SetCalendarDeletedFilterPayload, } from './filter-types'; import type { LifecycleState } from './lifecycle-state-types'; import type { LoadingStatus, LoadingInfo } from './loading-types'; import type { UpdateMultimediaMessageMediaPayload } from './media-types'; import type { MessageStore, RawMultimediaMessageInfo, FetchMessageInfosPayload, SendMessagePayload, SaveMessagesPayload, NewMessagesPayload, MessageStorePrunePayload, LocallyComposedMessageInfo, } from './message-types'; import type { RawTextMessageInfo } from './messages/text'; import type { BaseNavInfo } from './nav-types'; import type { ClearDeliveredReportsPayload, ClientReportCreationRequest, QueueReportsPayload, } from './report-types'; import type { ProcessServerRequestsPayload } from './request-types'; import type { UserSearchResult } from './search-types'; import type { SetSessionPayload } from './session-types'; import type { ConnectionInfo, StateSyncFullActionPayload, StateSyncIncrementalActionPayload, UpdateConnectionStatusPayload, SetLateResponsePayload, UpdateDisconnectedBarPayload, } from './socket-types'; import type { SubscriptionUpdateResult } from './subscription-types'; import type { ThreadStore, ChangeThreadSettingsPayload, LeaveThreadPayload, NewThreadResult, ThreadJoinPayload, } from './thread-types'; import type { UpdatesResultWithUserInfos } from './update-types'; import type { CurrentUserInfo, UserStore } from './user-types'; export type BaseAppState = { navInfo: NavInfo, currentUserInfo: ?CurrentUserInfo, entryStore: EntryStore, threadStore: ThreadStore, userStore: UserStore, messageStore: MessageStore, updatesCurrentAsOf: number, // millisecond timestamp loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, urlPrefix: string, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, - lifecycleState: boolean, + lifecycleState: LifecycleState, nextLocalID: number, queuedReports: $ReadOnlyArray, dataLoaded: boolean, }; // Web JS runtime doesn't have access to the cookie for security reasons. // Native JS doesn't have a sessionID because the cookieID is used instead. // Web JS doesn't have a device token because it's not a device... export type NativeAppState = BaseAppState<*> & { sessionID?: void, deviceToken: ?string, cookie: ?string, }; export type WebAppState = BaseAppState<*> & { sessionID: ?string, deviceToken?: void, cookie?: void, }; export type AppState = NativeAppState | WebAppState; export type BaseAction = | {| +type: '@@redux/INIT', +payload?: void, |} | {| +type: 'FETCH_ENTRIES_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_ENTRIES_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_ENTRIES_SUCCESS', +payload: FetchEntryInfosResult, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_OUT_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_OUT_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_OUT_SUCCESS', +payload: LogOutResult, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_ACCOUNT_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_ACCOUNT_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_ACCOUNT_SUCCESS', +payload: LogOutResult, +loadingInfo: LoadingInfo, |} | {| +type: 'CREATE_LOCAL_ENTRY', +payload: RawEntryInfo, |} | {| +type: 'CREATE_ENTRY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CREATE_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'CREATE_ENTRY_SUCCESS', +payload: CreateEntryPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SAVE_ENTRY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SAVE_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SAVE_ENTRY_SUCCESS', +payload: SaveEntryPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'CONCURRENT_MODIFICATION_RESET', +payload: {| +id: string, +dbText: string, |}, |} | {| +type: 'DELETE_ENTRY_STARTED', +loadingInfo: LoadingInfo, +payload: {| +localID: ?string, +serverID: ?string, |}, |} | {| +type: 'DELETE_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_ENTRY_SUCCESS', +payload: ?DeleteEntryResponse, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_IN_STARTED', +loadingInfo: LoadingInfo, +payload: LogInStartingPayload, |} | {| +type: 'LOG_IN_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'LOG_IN_SUCCESS', +payload: LogInResult, +loadingInfo: LoadingInfo, |} | {| +type: 'REGISTER_STARTED', +loadingInfo: LoadingInfo, +payload: LogInStartingPayload, |} | {| +type: 'REGISTER_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'REGISTER_SUCCESS', +payload: RegisterResult, +loadingInfo: LoadingInfo, |} | {| +type: 'RESET_PASSWORD_STARTED', +payload: {| calendarQuery: CalendarQuery |}, +loadingInfo: LoadingInfo, |} | {| +type: 'RESET_PASSWORD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'RESET_PASSWORD_SUCCESS', +payload: LogInResult, +loadingInfo: LoadingInfo, |} | {| +type: 'FORGOT_PASSWORD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FORGOT_PASSWORD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FORGOT_PASSWORD_SUCCESS', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_USER_SETTINGS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_USER_SETTINGS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_USER_SETTINGS_SUCCESS', +payload: {| +email: string, |}, +loadingInfo: LoadingInfo, |} | {| +type: 'RESEND_VERIFICATION_EMAIL_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'RESEND_VERIFICATION_EMAIL_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'RESEND_VERIFICATION_EMAIL_SUCCESS', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_SETTINGS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_SETTINGS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_SETTINGS_SUCCESS', +payload: ChangeThreadSettingsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'DELETE_THREAD_SUCCESS', +payload: LeaveThreadPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'NEW_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'NEW_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'NEW_THREAD_SUCCESS', +payload: NewThreadResult, +loadingInfo: LoadingInfo, |} | {| +type: 'REMOVE_USERS_FROM_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'REMOVE_USERS_FROM_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'REMOVE_USERS_FROM_THREAD_SUCCESS', +payload: ChangeThreadSettingsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_MEMBER_ROLES_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_MEMBER_ROLES_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'CHANGE_THREAD_MEMBER_ROLES_SUCCESS', +payload: ChangeThreadSettingsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_REVISIONS_FOR_ENTRY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_REVISIONS_FOR_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_REVISIONS_FOR_ENTRY_SUCCESS', +payload: {| +entryID: string, +text: string, +deleted: boolean, |}, +loadingInfo: LoadingInfo, |} | {| +type: 'RESTORE_ENTRY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'RESTORE_ENTRY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'RESTORE_ENTRY_SUCCESS', +payload: RestoreEntryPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'JOIN_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'JOIN_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'JOIN_THREAD_SUCCESS', +payload: ThreadJoinPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'LEAVE_THREAD_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'LEAVE_THREAD_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'LEAVE_THREAD_SUCCESS', +payload: LeaveThreadPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_NEW_SESSION', +payload: SetSessionPayload, |} | {| +type: 'persist/REHYDRATE', +payload: ?BaseAppState<*>, |} | {| +type: 'FETCH_MESSAGES_BEFORE_CURSOR_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MESSAGES_BEFORE_CURSOR_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MESSAGES_BEFORE_CURSOR_SUCCESS', +payload: FetchMessageInfosPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MOST_RECENT_MESSAGES_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MOST_RECENT_MESSAGES_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'FETCH_MOST_RECENT_MESSAGES_SUCCESS', +payload: FetchMessageInfosPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_TEXT_MESSAGE_STARTED', +loadingInfo: LoadingInfo, +payload: RawTextMessageInfo, |} | {| +type: 'SEND_TEXT_MESSAGE_FAILED', +error: true, +payload: Error & { +localID: string, +threadID: string, }, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_TEXT_MESSAGE_SUCCESS', +payload: SendMessagePayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_MULTIMEDIA_MESSAGE_STARTED', +loadingInfo: LoadingInfo, +payload: RawMultimediaMessageInfo, |} | {| +type: 'SEND_MULTIMEDIA_MESSAGE_FAILED', +error: true, +payload: Error & { +localID: string, +threadID: string, }, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_MULTIMEDIA_MESSAGE_SUCCESS', +payload: SendMessagePayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SEARCH_USERS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SEARCH_USERS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SEARCH_USERS_SUCCESS', +payload: UserSearchResult, +loadingInfo: LoadingInfo, |} | {| +type: 'SAVE_DRAFT', +payload: { +key: string, +draft: string, }, |} | {| +type: 'UPDATE_ACTIVITY_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_ACTIVITY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_ACTIVITY_SUCCESS', +payload: ActivityUpdateSuccessPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_DEVICE_TOKEN_STARTED', +payload: string, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_DEVICE_TOKEN_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_DEVICE_TOKEN_SUCCESS', +payload: string, +loadingInfo: LoadingInfo, |} | {| +type: 'HANDLE_VERIFICATION_CODE_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'HANDLE_VERIFICATION_CODE_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'HANDLE_VERIFICATION_CODE_SUCCESS', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORT_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORT_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORT_SUCCESS', +payload?: ClearDeliveredReportsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORTS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORTS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SEND_REPORTS_SUCCESS', +payload?: ClearDeliveredReportsPayload, +loadingInfo: LoadingInfo, |} | {| +type: 'QUEUE_REPORTS', +payload: QueueReportsPayload, |} | {| +type: 'SET_URL_PREFIX', +payload: string, |} | {| +type: 'SAVE_MESSAGES', +payload: SaveMessagesPayload, |} | {| +type: 'UPDATE_CALENDAR_THREAD_FILTER', +payload: CalendarThreadFilter, |} | {| +type: 'CLEAR_CALENDAR_THREAD_FILTER', +payload?: void, |} | {| +type: 'SET_CALENDAR_DELETED_FILTER', +payload: SetCalendarDeletedFilterPayload, |} | {| +type: 'UPDATE_SUBSCRIPTION_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_SUBSCRIPTION_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_SUBSCRIPTION_SUCCESS', +payload: SubscriptionUpdateResult, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_CALENDAR_QUERY_STARTED', +loadingInfo: LoadingInfo, +payload?: CalendarQueryUpdateStartingPayload, |} | {| +type: 'UPDATE_CALENDAR_QUERY_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_CALENDAR_QUERY_SUCCESS', +payload: CalendarQueryUpdateResult, +loadingInfo: LoadingInfo, |} | {| +type: 'FULL_STATE_SYNC', +payload: StateSyncFullActionPayload, |} | {| +type: 'INCREMENTAL_STATE_SYNC', +payload: StateSyncIncrementalActionPayload, |} | {| +type: 'PROCESS_SERVER_REQUESTS', +payload: ProcessServerRequestsPayload, |} | {| +type: 'UPDATE_CONNECTION_STATUS', +payload: UpdateConnectionStatusPayload, |} | {| +type: 'QUEUE_ACTIVITY_UPDATES', +payload: QueueActivityUpdatesPayload, |} | {| +type: 'UNSUPERVISED_BACKGROUND', +payload?: void, |} | {| +type: 'UPDATE_LIFECYCLE_STATE', +payload: LifecycleState, |} | {| +type: 'PROCESS_UPDATES', +payload: UpdatesResultWithUserInfos, |} | {| +type: 'PROCESS_MESSAGES', +payload: NewMessagesPayload, |} | {| +type: 'MESSAGE_STORE_PRUNE', +payload: MessageStorePrunePayload, |} | {| +type: 'SET_LATE_RESPONSE', +payload: SetLateResponsePayload, |} | {| +type: 'UPDATE_DISCONNECTED_BAR', +payload: UpdateDisconnectedBarPayload, |} | {| +type: 'REQUEST_ACCESS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'REQUEST_ACCESS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'REQUEST_ACCESS_SUCCESS', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA', +payload: UpdateMultimediaMessageMediaPayload, |} | {| +type: 'CREATE_LOCAL_MESSAGE', +payload: LocallyComposedMessageInfo, |} | {| +type: 'UPDATE_RELATIONSHIPS_STARTED', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_RELATIONSHIPS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'UPDATE_RELATIONSHIPS_SUCCESS', +payload?: void, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_THREAD_UNREAD_STATUS_STARTED', +payload: {| +threadID: string, +unread: boolean, |}, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_THREAD_UNREAD_STATUS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_THREAD_UNREAD_STATUS_SUCCESS', +payload: SetThreadUnreadStatusPayload, |}; export type ActionPayload = ?(Object | Array<*> | $ReadOnlyArray<*> | string); export type SuperAction = { type: string, payload?: ActionPayload, loadingInfo?: LoadingInfo, error?: boolean, }; type ThunkedAction = (dispatch: Dispatch) => void; export type PromisedAction = (dispatch: Dispatch) => Promise; export type Dispatch = ((promisedAction: PromisedAction) => Promise) & ((thunkedAction: ThunkedAction) => void) & ((action: SuperAction) => boolean); // This is lifted from redux-persist/lib/constants.js // I don't want to add redux-persist to the web/server bundles... // import { REHYDRATE } from 'redux-persist'; export const rehydrateActionType = 'persist/REHYDRATE'; diff --git a/native/lifecycle/lifecycle-handler.react.js b/native/lifecycle/lifecycle-handler.react.js index f915b946d..f0e60e56c 100644 --- a/native/lifecycle/lifecycle-handler.react.js +++ b/native/lifecycle/lifecycle-handler.react.js @@ -1,45 +1,52 @@ // @flow import * as React from 'react'; import { useDispatch } from 'react-redux'; import { updateLifecycleStateActionType } from 'lib/reducers/lifecycle-state-reducer'; import type { LifecycleState } from 'lib/types/lifecycle-state-types'; import { appBecameInactive } from '../redux/redux-setup'; import { addLifecycleListener } from './lifecycle'; const LifecycleHandler = React.memo<{||}>(() => { const dispatch = useDispatch(); const lastStateRef = React.useRef(); const onLifecycleChange = React.useCallback( (nextState: ?LifecycleState) => { if (!nextState || nextState === 'unknown') { return; } const lastState = lastStateRef.current; lastStateRef.current = nextState; - if (lastState === 'background' && nextState === 'active') { + if ( + (lastState === 'background' || lastState === 'inactive') && + nextState === 'active' + ) { dispatch({ type: updateLifecycleStateActionType, payload: 'active' }); - } else if (lastState !== 'background' && nextState === 'background') { + } else if ( + lastState !== 'background' && + lastState !== 'inactive' && + (nextState === 'background' || nextState === 'inactive') + ) { dispatch({ type: updateLifecycleStateActionType, - payload: 'background', + payload: nextState, }); appBecameInactive(); } }, [lastStateRef, dispatch], ); React.useEffect(() => { const subscription = addLifecycleListener(onLifecycleChange); return () => subscription.remove(); }, [onLifecycleChange]); return null; }); LifecycleHandler.displayName = 'LifecycleHandler'; export default LifecycleHandler; diff --git a/native/redux/redux-setup.js b/native/redux/redux-setup.js index c02309d61..255f5853d 100644 --- a/native/redux/redux-setup.js +++ b/native/redux/redux-setup.js @@ -1,438 +1,439 @@ // @flow import { AppState as NativeAppState, Platform, Alert } from 'react-native'; import type { Orientations } from 'react-native-orientation-locker'; import Orientation from 'react-native-orientation-locker'; import { createStore, applyMiddleware, type Store, compose } from 'redux'; import { persistStore, persistReducer } from 'redux-persist'; import type { PersistState } from 'redux-persist/src/types'; import thunk from 'redux-thunk'; import { setDeviceTokenActionTypes } from 'lib/actions/device-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, } from 'lib/actions/user-actions'; import baseReducer from 'lib/reducers/master-reducer'; import { invalidSessionDowngrade, invalidSessionRecovery, } from 'lib/shared/account-utils'; import { type EntryStore } from 'lib/types/entry-types'; import { type CalendarFilter, defaultCalendarFilters, } from 'lib/types/filter-types'; +import type { LifecycleState } from 'lib/types/lifecycle-state-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { MessageStore } from 'lib/types/message-types'; import type { Dispatch } from 'lib/types/redux-types'; import type { ClientReportCreationRequest } from 'lib/types/report-types'; import type { SetSessionPayload } from 'lib/types/session-types'; import { type ConnectionInfo, defaultConnectionInfo, incrementalStateSyncActionType, } from 'lib/types/socket-types'; import type { ThreadStore } from 'lib/types/thread-types'; import { updateTypes } from 'lib/types/update-types'; import type { CurrentUserInfo, UserStore } from 'lib/types/user-types'; import { reduxLoggerMiddleware } from 'lib/utils/action-logger'; import { setNewSessionActionType } from 'lib/utils/action-utils'; import { type NavInfo, defaultNavInfo } from '../navigation/default-state'; import { getGlobalNavContext } from '../navigation/icky-global'; import { activeMessageListSelector } from '../navigation/nav-selectors'; import { type NotifPermissionAlertInfo, defaultNotifPermissionAlertInfo, } from '../push/alerts'; import { reduceThreadIDsToNotifIDs } from '../push/reducer'; import reactotron from '../reactotron'; import reduceDrafts from '../reducers/draft-reducer'; import { type DeviceCameraInfo, defaultDeviceCameraInfo, } from '../types/camera'; import { type ConnectivityInfo, defaultConnectivityInfo, } from '../types/connectivity'; import { type GlobalThemeInfo, defaultGlobalThemeInfo } from '../types/themes'; import { defaultURLPrefix, natServer, setCustomServer, } from '../utils/url-utils'; import { resetUserStateActionType, recordNotifPermissionAlertActionType, recordAndroidNotificationActionType, clearAndroidNotificationsActionType, rescindAndroidNotificationActionType, updateDimensionsActiveType, updateConnectivityActiveType, updateThemeInfoActionType, updateDeviceCameraInfoActionType, updateDeviceOrientationActionType, updateThreadLastNavigatedActionType, backgroundActionTypes, setReduxStateActionType, } from './action-types'; import { defaultDimensionsInfo, type DimensionsInfo, } from './dimensions-updater.react'; import { persistConfig, setPersistor } from './persist'; export type AppState = {| navInfo: NavInfo, currentUserInfo: ?CurrentUserInfo, entryStore: EntryStore, threadStore: ThreadStore, userStore: UserStore, messageStore: MessageStore, drafts: { [key: string]: string }, updatesCurrentAsOf: number, loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, cookie: ?string, deviceToken: ?string, dataLoaded: boolean, urlPrefix: string, customServer: ?string, threadIDsToNotifIDs: { [threadID: string]: string[] }, notifPermissionAlertInfo: NotifPermissionAlertInfo, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, - lifecycleState: boolean, + lifecycleState: LifecycleState, nextLocalID: number, queuedReports: $ReadOnlyArray, _persist: ?PersistState, sessionID?: void, dimensions: DimensionsInfo, connectivity: ConnectivityInfo, globalThemeInfo: GlobalThemeInfo, deviceCameraInfo: DeviceCameraInfo, deviceOrientation: Orientations, frozen: boolean, |}; const defaultState = ({ navInfo: defaultNavInfo, currentUserInfo: null, entryStore: { entryInfos: {}, daysToEntries: {}, lastUserInteractionCalendar: 0, inconsistencyReports: [], }, threadStore: { threadInfos: {}, inconsistencyReports: [], }, userStore: { userInfos: {}, inconsistencyReports: [], }, messageStore: { messages: {}, threads: {}, local: {}, currentAsOf: 0, }, drafts: {}, updatesCurrentAsOf: 0, loadingStatuses: {}, calendarFilters: defaultCalendarFilters, cookie: null, deviceToken: null, dataLoaded: false, urlPrefix: defaultURLPrefix(), customServer: natServer, threadIDsToNotifIDs: {}, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, connection: defaultConnectionInfo(Platform.OS), watchedThreadIDs: [], - lifecycleState: true, + lifecycleState: 'active', nextLocalID: 0, queuedReports: [], _persist: null, dimensions: defaultDimensionsInfo, connectivity: defaultConnectivityInfo, globalThemeInfo: defaultGlobalThemeInfo, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), frozen: false, }: AppState); function reducer(state: AppState = defaultState, action: *) { if (action.type === setReduxStateActionType) { return action.state; } if ( (action.type === setNewSessionActionType && invalidSessionDowngrade( state, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === logOutActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === deleteAccountActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, )) ) { return state; } if ( (action.type === setNewSessionActionType && invalidSessionRecovery( state, action.payload.sessionChange.currentUserInfo, action.payload.source, )) || (action.type === logInActionTypes.success && invalidSessionRecovery( state, action.payload.currentUserInfo, action.payload.source, )) ) { return state; } if ( action.type === recordAndroidNotificationActionType || action.type === clearAndroidNotificationsActionType || action.type === rescindAndroidNotificationActionType ) { return { ...state, threadIDsToNotifIDs: reduceThreadIDsToNotifIDs( state.threadIDsToNotifIDs, action, ), }; } else if (action.type === setCustomServer) { return { ...state, customServer: action.payload, }; } else if (action.type === recordNotifPermissionAlertActionType) { return { ...state, notifPermissionAlertInfo: { totalAlerts: state.notifPermissionAlertInfo.totalAlerts + 1, lastAlertTime: action.payload.time, }, }; } else if (action.type === resetUserStateActionType) { const cookie = state.cookie && state.cookie.startsWith('anonymous=') ? state.cookie : null; const currentUserInfo = state.currentUserInfo && state.currentUserInfo.anonymous ? state.currentUserInfo : null; return { ...state, currentUserInfo, cookie, }; } else if (action.type === updateDimensionsActiveType) { return { ...state, dimensions: { ...state.dimensions, ...action.payload, }, }; } else if (action.type === updateConnectivityActiveType) { return { ...state, connectivity: action.payload, }; } else if (action.type === updateThemeInfoActionType) { return { ...state, globalThemeInfo: { ...state.globalThemeInfo, ...action.payload, }, }; } else if (action.type === updateDeviceCameraInfoActionType) { return { ...state, deviceCameraInfo: { ...state.deviceCameraInfo, ...action.payload, }, }; } else if (action.type === updateDeviceOrientationActionType) { return { ...state, deviceOrientation: action.payload, }; } else if (action.type === setDeviceTokenActionTypes.started) { return { ...state, deviceToken: action.payload, }; } else if (action.type === updateThreadLastNavigatedActionType) { const { threadID, time } = action.payload; if (state.messageStore.threads[threadID]) { state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, [threadID]: { ...state.messageStore.threads[threadID], lastNavigatedTo: time, }, }, }, }; } } if (action.type === setNewSessionActionType) { sessionInvalidationAlert(action.payload); state = { ...state, cookie: action.payload.sessionChange.cookie, }; } else if (action.type === incrementalStateSyncActionType) { let wipeDeviceToken = false; for (let update of action.payload.updatesResult.newUpdates) { if ( update.type === updateTypes.BAD_DEVICE_TOKEN && update.deviceToken === state.deviceToken ) { wipeDeviceToken = true; break; } } if (wipeDeviceToken) { state = { ...state, deviceToken: null, }; } } state = { ...baseReducer(state, action), drafts: reduceDrafts(state.drafts, action), }; return fixUnreadActiveThread(state, action); } function sessionInvalidationAlert(payload: SetSessionPayload) { if ( !payload.sessionChange.cookieInvalidated || !payload.preRequestUserState || !payload.preRequestUserState.currentUserInfo || payload.preRequestUserState.currentUserInfo.anonymous ) { return; } if (payload.error === 'client_version_unsupported') { const app = Platform.select({ ios: 'Testflight', android: 'Play Store', }); Alert.alert( 'App out of date', "Your app version is pretty old, and the server doesn't know how to " + `speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK' }], { cancelable: true }, ); } else { Alert.alert( 'Session invalidated', "We're sorry, but your session was invalidated by the server. " + 'Please log in again.', [{ text: 'OK' }], { cancelable: true }, ); } } // Makes sure a currently focused thread is never unread. Note that we consider // a backgrounded NativeAppState to actually be active if it last changed to // inactive more than 10 seconds ago. This is because there is a delay when // NativeAppState is updating in response to a foreground, and actions don't get // processed more than 10 seconds after a backgrounding anyways. However we // don't consider this for action types that can be expected to happen while the // app is backgrounded. function fixUnreadActiveThread(state: AppState, action: *): AppState { const navContext = getGlobalNavContext(); const activeThread = activeMessageListSelector(navContext); if ( activeThread && (NativeAppState.currentState === 'active' || (appLastBecameInactive + 10000 < Date.now() && !backgroundActionTypes.has(action.type))) && state.threadStore.threadInfos[activeThread] && state.threadStore.threadInfos[activeThread].currentUser.unread ) { state = { ...state, threadStore: { ...state.threadStore, threadInfos: { ...state.threadStore.threadInfos, [activeThread]: { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].currentUser, unread: false, }, }, }, }, }; } return state; } let appLastBecameInactive = 0; function appBecameInactive() { appLastBecameInactive = Date.now(); } const middleware = applyMiddleware(thunk, reduxLoggerMiddleware); const composeFunc = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: 'Redux' }) : compose; let enhancers; if (reactotron) { enhancers = composeFunc(middleware, reactotron.createEnhancer()); } else { enhancers = composeFunc(middleware); } const store: Store = createStore( persistReducer(persistConfig, reducer), defaultState, enhancers, ); const persistor = persistStore(store); setPersistor(persistor); const unsafeDispatch: any = store.dispatch; const dispatch: Dispatch = unsafeDispatch; export { store, dispatch, appBecameInactive }; diff --git a/native/socket.react.js b/native/socket.react.js index 244260e11..c20915203 100644 --- a/native/socket.react.js +++ b/native/socket.react.js @@ -1,105 +1,105 @@ // @flow import * as React from 'react'; import { useDispatch } from 'react-redux'; import { logOut } from 'lib/actions/user-actions'; import { preRequestUserStateSelector } from 'lib/selectors/account-selectors'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import Socket, { type BaseSocketProps } from 'lib/socket/socket.react'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import { InputStateContext } from './input/input-state'; import { activeMessageListSelector, nativeCalendarQuery, } from './navigation/nav-selectors'; import { NavContext } from './navigation/navigation-context'; import { useSelector } from './redux/redux-utils'; import { openSocketSelector, sessionIdentificationSelector, nativeGetClientResponsesSelector, nativeSessionStateFuncSelector, } from './selectors/socket-selectors'; export default React.memo(function NativeSocket( props: BaseSocketProps, ) { const inputState = React.useContext(InputStateContext); const navContext = React.useContext(NavContext); const cookie = useSelector((state) => state.cookie); const urlPrefix = useSelector((state) => state.urlPrefix); const connection = useSelector((state) => state.connection); const frozen = useSelector((state) => state.frozen); const active = useSelector( - (state) => isLoggedIn(state) && state.lifecycleState, + (state) => isLoggedIn(state) && state.lifecycleState !== 'background', ); const openSocket = useSelector(openSocketSelector); const sessionIdentification = useSelector(sessionIdentificationSelector); const preRequestUserState = useSelector(preRequestUserStateSelector); const getClientResponses = useSelector((state) => nativeGetClientResponsesSelector({ redux: state, navContext, }), ); const sessionStateFunc = useSelector((state) => nativeSessionStateFuncSelector({ redux: state, navContext, }), ); const currentCalendarQuery = useSelector((state) => nativeCalendarQuery({ redux: state, navContext, }), ); const canSendReports = useSelector( (state) => !state.frozen && state.connectivity.hasWiFi && (!inputState || !inputState.uploadInProgress()), ); const activeThread = React.useMemo(() => { if (!active) { return null; } return activeMessageListSelector(navContext); }, [active, navContext]); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callLogOut = useServerCall(logOut); return ( ); }); diff --git a/server/src/responders/website-responders.js b/server/src/responders/website-responders.js index 700928006..8d9b9d994 100644 --- a/server/src/responders/website-responders.js +++ b/server/src/responders/website-responders.js @@ -1,331 +1,331 @@ // @flow import html from 'common-tags/lib/html'; import type { $Response, $Request } from 'express'; import fs from 'fs'; import _keyBy from 'lodash/fp/keyBy'; import React from 'react'; import ReactDOMServer from 'react-dom/server'; import { Provider } from 'react-redux'; import { Route, StaticRouter } from 'react-router'; import { createStore, type Store } from 'redux'; import { promisify } from 'util'; import { daysToEntriesFromEntryInfos } from 'lib/reducers/entry-reducer'; import { freshMessageStore } from 'lib/reducers/message-reducer'; import { mostRecentReadThread } from 'lib/selectors/thread-selectors'; import { mostRecentMessageTimestamp } from 'lib/shared/message-utils'; import { threadHasPermission } from 'lib/shared/thread-utils'; import { defaultCalendarFilters } from 'lib/types/filter-types'; import { defaultNumberPerThread } from 'lib/types/message-types'; import { defaultConnectionInfo } from 'lib/types/socket-types'; import { threadPermissions } from 'lib/types/thread-types'; import type { ServerVerificationResult } from 'lib/types/verify-types'; import { currentDateInTimeZone } from 'lib/utils/date-utils'; import { ServerError } from 'lib/utils/errors'; import { promiseAll } from 'lib/utils/promises'; import App from 'web/dist/app.build.cjs'; import { reducer } from 'web/redux/redux-setup'; import type { AppState, Action } from 'web/redux/redux-setup'; import getTitle from 'web/title/getTitle'; import { navInfoFromURL } from 'web/url-utils'; import urlFacts from '../../facts/url'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { fetchCurrentUserInfo, fetchKnownUserInfos, } from '../fetchers/user-fetchers'; import { handleCodeVerificationRequest } from '../models/verification'; import { setNewSession } from '../session/cookies'; import { Viewer } from '../session/viewer'; import { streamJSON, waitForStream } from '../utils/json-stream'; const { basePath, baseDomain } = urlFacts; const { renderToNodeStream } = ReactDOMServer; const baseURL = basePath.replace(/\/$/, ''); const baseHref = baseDomain + baseURL; const access = promisify(fs.access); const googleFontsURL = 'https://fonts.googleapis.com/css?family=Open+Sans:300,600%7CAnaheim'; const localFontsURL = 'fonts/local-fonts.css'; async function getFontsURL() { try { await access(localFontsURL); return localFontsURL; } catch { return googleFontsURL; } } type AssetInfo = {| jsURL: string, fontsURL: string, cssInclude: string |}; let assetInfo: ?AssetInfo = null; async function getAssetInfo() { if (assetInfo) { return assetInfo; } if (process.env.NODE_ENV === 'dev') { const fontsURL = await getFontsURL(); assetInfo = { jsURL: 'http://localhost:8080/dev.build.js', fontsURL, cssInclude: '', }; return assetInfo; } // $FlowFixMe compiled/assets.json doesn't always exist const { default: assets } = await import('../../compiled/assets'); assetInfo = { jsURL: `compiled/${assets.browser.js}`, fontsURL: googleFontsURL, cssInclude: html` `, }; return assetInfo; } async function websiteResponder( viewer: Viewer, req: $Request, res: $Response, ): Promise { let initialNavInfo; try { initialNavInfo = navInfoFromURL(req.url, { now: currentDateInTimeZone(viewer.timeZone), }); } catch (e) { throw new ServerError(e.message); } const calendarQuery = { startDate: initialNavInfo.startDate, endDate: initialNavInfo.endDate, filters: defaultCalendarFilters, }; const threadSelectionCriteria = { joinedThreads: true }; const initialTime = Date.now(); const assetInfoPromise = getAssetInfo(); const threadInfoPromise = fetchThreadInfos(viewer); const messageInfoPromise = fetchMessageInfos( viewer, threadSelectionCriteria, defaultNumberPerThread, ); const entryInfoPromise = fetchEntryInfos(viewer, [calendarQuery]); const currentUserInfoPromise = fetchCurrentUserInfo(viewer); const serverVerificationResultPromise = handleVerificationRequest( viewer, initialNavInfo.verify, ); const userInfoPromise = fetchKnownUserInfos(viewer); const sessionIDPromise = (async () => { if (viewer.loggedIn) { await setNewSession(viewer, calendarQuery, initialTime); } return viewer.sessionID; })(); const threadStorePromise = (async () => { const { threadInfos } = await threadInfoPromise; return { threadInfos, inconsistencyReports: [] }; })(); const messageStorePromise = (async () => { const [ { threadInfos }, { rawMessageInfos, truncationStatuses }, ] = await Promise.all([threadInfoPromise, messageInfoPromise]); return freshMessageStore( rawMessageInfos, truncationStatuses, mostRecentMessageTimestamp(rawMessageInfos, initialTime), threadInfos, ); })(); const entryStorePromise = (async () => { const { rawEntryInfos } = await entryInfoPromise; return { entryInfos: _keyBy('id')(rawEntryInfos), daysToEntries: daysToEntriesFromEntryInfos(rawEntryInfos), lastUserInteractionCalendar: initialTime, inconsistencyReports: [], }; })(); const userStorePromise = (async () => { const userInfos = await userInfoPromise; return { userInfos, inconsistencyReports: [] }; })(); const navInfoPromise = (async () => { const [{ threadInfos }, messageStore] = await Promise.all([ threadInfoPromise, messageStorePromise, ]); let finalNavInfo = initialNavInfo; const requestedActiveChatThreadID = finalNavInfo.activeChatThreadID; if ( requestedActiveChatThreadID && !threadHasPermission( threadInfos[requestedActiveChatThreadID], threadPermissions.VISIBLE, ) ) { finalNavInfo.activeChatThreadID = null; } if (!finalNavInfo.activeChatThreadID) { const mostRecentThread = mostRecentReadThread(messageStore, threadInfos); if (mostRecentThread) { finalNavInfo.activeChatThreadID = mostRecentThread; } } return finalNavInfo; })(); const { jsURL, fontsURL, cssInclude } = await assetInfoPromise; // prettier-ignore res.write(html` ${getTitle(0)} ${cssInclude}
`); const statePromises = { navInfo: navInfoPromise, currentUserInfo: currentUserInfoPromise, sessionID: sessionIDPromise, serverVerificationResult: serverVerificationResultPromise, entryStore: entryStorePromise, threadStore: threadStorePromise, userStore: userStorePromise, messageStore: messageStorePromise, updatesCurrentAsOf: initialTime, loadingStatuses: {}, calendarFilters: defaultCalendarFilters, // We can use paths local to the on web urlPrefix: '', windowDimensions: { width: 0, height: 0 }, baseHref, connection: { ...defaultConnectionInfo('web', viewer.timeZone), actualizedCalendarQuery: calendarQuery, }, watchedThreadIDs: [], - lifecycleState: true, + lifecycleState: 'active', nextLocalID: 0, queuedReports: [], timeZone: viewer.timeZone, userAgent: viewer.userAgent, cookie: undefined, deviceToken: undefined, dataLoaded: viewer.loggedIn, windowActive: true, }; const stateResult = await promiseAll(statePromises); const state: AppState = { ...stateResult }; const store: Store = createStore(reducer, state); const routerContext = {}; const reactStream = renderToNodeStream( , ); if (routerContext.url) { throw new ServerError('URL modified during server render!'); } reactStream.pipe(res, { end: false }); await waitForStream(reactStream); res.write(html`
`); } async function handleVerificationRequest( viewer: Viewer, code: ?string, ): Promise { if (!code) { return null; } try { return await handleCodeVerificationRequest(viewer, code); } catch (e) { if (e instanceof ServerError && e.message === 'invalid_code') { return { success: false }; } throw e; } } export { websiteResponder }; diff --git a/web/redux/redux-setup.js b/web/redux/redux-setup.js index 4de0f17b2..d921dfde7 100644 --- a/web/redux/redux-setup.js +++ b/web/redux/redux-setup.js @@ -1,220 +1,221 @@ // @flow import invariant from 'invariant'; import PropTypes from 'prop-types'; import { logOutActionTypes, deleteAccountActionTypes, } from 'lib/actions/user-actions'; import baseReducer from 'lib/reducers/master-reducer'; import { mostRecentReadThreadSelector } from 'lib/selectors/thread-selectors'; import { invalidSessionDowngrade } from 'lib/shared/account-utils'; import type { Shape } from 'lib/types/core'; import type { EntryStore } from 'lib/types/entry-types'; import type { CalendarFilter } from 'lib/types/filter-types'; +import type { LifecycleState } from 'lib/types/lifecycle-state-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { MessageStore } from 'lib/types/message-types'; import type { BaseNavInfo } from 'lib/types/nav-types'; import type { BaseAction } from 'lib/types/redux-types'; import type { ClientReportCreationRequest } from 'lib/types/report-types'; import type { ConnectionInfo } from 'lib/types/socket-types'; import type { ThreadInfo, ThreadStore } from 'lib/types/thread-types'; import { threadInfoPropType } from 'lib/types/thread-types'; import type { CurrentUserInfo, UserStore } from 'lib/types/user-types'; import type { ServerVerificationResult } from 'lib/types/verify-types'; import { setNewSessionActionType } from 'lib/utils/action-utils'; import { activeThreadSelector } from '../selectors/nav-selectors'; import { updateWindowActiveActionType } from './action-types'; import { getVisibility } from './visibility'; export type NavInfo = {| ...$Exact, +tab: 'calendar' | 'chat', +verify: ?string, +activeChatThreadID: ?string, +pendingThread?: ThreadInfo, +sourceMessageID?: string, |}; export const navInfoPropType = PropTypes.shape({ startDate: PropTypes.string.isRequired, endDate: PropTypes.string.isRequired, tab: PropTypes.oneOf(['calendar', 'chat']).isRequired, verify: PropTypes.string, activeChatThreadID: PropTypes.string, pendingThread: threadInfoPropType, sourceMessageID: PropTypes.string, }); export type WindowDimensions = {| width: number, height: number |}; export type AppState = {| navInfo: NavInfo, currentUserInfo: ?CurrentUserInfo, sessionID: ?string, serverVerificationResult: ?ServerVerificationResult, entryStore: EntryStore, threadStore: ThreadStore, userStore: UserStore, messageStore: MessageStore, updatesCurrentAsOf: number, loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, urlPrefix: string, windowDimensions: WindowDimensions, cookie?: void, deviceToken?: void, baseHref: string, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, - lifecycleState: boolean, + lifecycleState: LifecycleState, nextLocalID: number, queuedReports: $ReadOnlyArray, timeZone: ?string, userAgent: ?string, dataLoaded: boolean, windowActive: boolean, |}; export const updateNavInfoActionType = 'UPDATE_NAV_INFO'; export const updateWindowDimensions = 'UPDATE_WINDOW_DIMENSIONS'; export type Action = | BaseAction | {| type: 'UPDATE_NAV_INFO', payload: Shape |} | {| type: 'UPDATE_WINDOW_DIMENSIONS', payload: WindowDimensions, |} | {| type: 'UPDATE_WINDOW_ACTIVE', payload: boolean, |}; export function reducer(oldState: AppState | void, action: Action) { invariant(oldState, 'should be set'); let state = oldState; if (action.type === updateNavInfoActionType) { return validateState(oldState, { ...state, navInfo: { ...state.navInfo, ...action.payload, }, }); } else if (action.type === updateWindowDimensions) { return validateState(oldState, { ...state, windowDimensions: action.payload, }); } else if (action.type === updateWindowActiveActionType) { return validateState(oldState, { ...state, windowActive: action.payload, }); } else if (action.type === setNewSessionActionType) { if ( invalidSessionDowngrade( oldState, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, ) ) { return oldState; } state = { ...state, sessionID: action.payload.sessionChange.sessionID, }; } else if ( (action.type === logOutActionTypes.success && invalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === deleteAccountActionTypes.success && invalidSessionDowngrade( oldState, action.payload.currentUserInfo, action.payload.preRequestUserState, )) ) { return oldState; } return validateState(oldState, baseReducer(state, action)); } function validateState(oldState: AppState, state: AppState): AppState { if ( state.navInfo.activeChatThreadID && !state.navInfo.pendingThread && !state.threadStore.threadInfos[state.navInfo.activeChatThreadID] ) { // Makes sure the active thread always exists state = { ...state, navInfo: { ...state.navInfo, activeChatThreadID: mostRecentReadThreadSelector(state), }, }; } const activeThread = activeThreadSelector(state); if ( activeThread && !getVisibility().hidden() && typeof document !== 'undefined' && document && document.hasFocus && document.hasFocus() && !state.navInfo.pendingThread && state.threadStore.threadInfos[activeThread].currentUser.unread ) { // Makes sure a currently focused thread is never unread state = { ...state, threadStore: { ...state.threadStore, threadInfos: { ...state.threadStore.threadInfos, [activeThread]: { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].currentUser, unread: false, }, }, }, }, }; } const oldActiveThread = activeThreadSelector(oldState); if ( activeThread && oldActiveThread !== activeThread && state.messageStore.threads[activeThread] ) { // Update messageStore.threads[activeThread].lastNavigatedTo state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, [activeThread]: { ...state.messageStore.threads[activeThread], lastNavigatedTo: Date.now(), }, }, }, }; } return state; } diff --git a/web/socket.react.js b/web/socket.react.js index 30457515d..e61590453 100644 --- a/web/socket.react.js +++ b/web/socket.react.js @@ -1,80 +1,80 @@ // @flow import * as React from 'react'; import { useDispatch } from 'react-redux'; import { logOut } from 'lib/actions/user-actions'; import { preRequestUserStateSelector } from 'lib/selectors/account-selectors'; import Socket, { type BaseSocketProps } from 'lib/socket/socket.react'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import { useSelector } from './redux/redux-utils'; import { activeThreadSelector, webCalendarQuery, } from './selectors/nav-selectors'; import { openSocketSelector, sessionIdentificationSelector, webGetClientResponsesSelector, webSessionStateFuncSelector, } from './selectors/socket-selectors'; export default React.memo(function WebSocket( props: BaseSocketProps, ) { const cookie = useSelector((state) => state.cookie); const urlPrefix = useSelector((state) => state.urlPrefix); const connection = useSelector((state) => state.connection); const active = useSelector( (state) => !!state.currentUserInfo && !state.currentUserInfo.anonymous && - state.lifecycleState, + state.lifecycleState !== 'background', ); const openSocket = useSelector(openSocketSelector); const sessionIdentification = useSelector(sessionIdentificationSelector); const preRequestUserState = useSelector(preRequestUserStateSelector); const getClientResponses = useSelector(webGetClientResponsesSelector); const sessionStateFunc = useSelector(webSessionStateFuncSelector); const currentCalendarQuery = useSelector(webCalendarQuery); const reduxActiveThread = useSelector(activeThreadSelector); const windowActive = useSelector((state) => state.windowActive); const activeThread = React.useMemo(() => { if (!active || !windowActive) { return null; } return reduxActiveThread; }, [active, windowActive, reduxActiveThread]); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callLogOut = useServerCall(logOut); return ( ); });